diff options
Diffstat (limited to 'app/[lng]')
5 files changed, 504 insertions, 1 deletions
diff --git a/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx index c3d429d1..c9e6b0e3 100644 --- a/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx +++ b/app/[lng]/evcp/(evcp)/(master-data)/projects/page.tsx @@ -36,7 +36,7 @@ export default async function IndexPage(props: IndexPageProps) { <div> <div className="flex items-center gap-2"> <h2 className="text-2xl font-bold tracking-tight"> - 프로젝트 리스트 from S-EDP + 프로젝트 리스트 </h2> <InformationButton pagePath="evcp/projects" /> </div> diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx new file mode 100644 index 00000000..28a85e50 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/contract-detail-client.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ChevronLeft } from "lucide-react" +import Link from "next/link" +import { toast } from "sonner" +import { saveSHIComment } from "@/lib/po/vendor-table/service" +import { useRouter } from "next/navigation" +import { ContractInfoCard } from "@/components/contract/contract-info-card" +import { ContractItemsCard } from "@/components/contract/contract-items-card" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { AlertCircle } from "lucide-react" + +interface ContractDetailClientProps { + contract: any + lng: string +} + +export function ContractDetailClient({ contract, lng }: ContractDetailClientProps) { + const router = useRouter() + const [shiComment, setSHIComment] = React.useState(contract.shiComment || "") + const [isLoading, setIsLoading] = React.useState(false) + + const handleSaveComment = async () => { + try { + setIsLoading(true) + const result = await saveSHIComment(contract.id, shiComment) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error("의견 저장에 실패했습니다.") + } + } catch (error) { + toast.error("의견 저장 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + <> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Link href={`/${lng}/evcp/po`}> + <Button variant="ghost" size="icon"> + <ChevronLeft className="h-5 w-5" /> + </Button> + </Link> + <div className="flex items-center gap-3"> + <h1 className="text-2xl font-bold tracking-tight">계약 상세</h1> + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground">계약번호:</span> + <span className="text-sm font-medium">{contract.contractNo}</span> + <Badge variant="outline" className="ml-2"> + {contract.status} + </Badge> + </div> + </div> + </div> + <div className="flex gap-2"> + <Button + variant="default" + onClick={handleSaveComment} + disabled={isLoading} + > + 의견저장 + </Button> + </div> + </div> + + {/* 계약 거절 사유 표시 (있는 경우) */} + {contract.rejectionReason && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>계약 거절 사유:</strong> {contract.rejectionReason} + </AlertDescription> + </Alert> + )} + + {/* 계약 조건 */} + <ContractInfoCard contract={contract} /> + + {/* 코멘트 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">코멘트</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <label htmlFor="vendor-comment" className="text-sm font-medium text-muted-foreground"> + Vendor Comment + </label> + <div className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md bg-muted/50"> + {contract.vendorComment || "코멘트가 없습니다."} + </div> + </div> + <div className="space-y-2"> + <label htmlFor="shi-comment" className="text-sm font-medium"> + SHI Comment + </label> + <textarea + id="shi-comment" + className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="SHI 코멘트를 입력하세요..." + value={shiComment} + onChange={(e) => setSHIComment(e.target.value)} + /> + </div> + </div> + </CardContent> + </Card> + + {/* 계약문서 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">계약문서</CardTitle> + </CardHeader> + <CardContent> + {contract.contractContent ? ( + <div className="prose prose-sm max-w-none"> + <pre className="whitespace-pre-wrap text-sm bg-muted/50 p-4 rounded-md"> + {contract.contractContent} + </pre> + </div> + ) : ( + <p className="text-sm text-muted-foreground">계약문서 내용이 없습니다.</p> + )} + </CardContent> + </Card> + + {/* 계약 품목 */} + <ContractItemsCard items={contract.items || []} currency={contract.currency} /> + </> + ) +} + diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx new file mode 100644 index 00000000..226d960d --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(procurement)/po/[id]/page.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { redirect } from "next/navigation" +import { getContractDetail } from "@/lib/po/vendor-table/service" +import { Shell } from "@/components/shell" +import { ContractDetailClient } from "./contract-detail-client" + +interface ContractDetailPageProps { + params: Promise<{ + id: string + lng: string + }> +} + +export default async function EVCPContractDetailPage(props: ContractDetailPageProps) { + const params = await props.params + const contractId = parseInt(params.id, 10) + + // 유효하지 않은 ID 체크 + if (isNaN(contractId) || contractId <= 0) { + return ( + <Shell className="gap-4"> + <div className="flex h-full items-center justify-center p-6"> + <p className="text-muted-foreground">유효하지 않은 계약 ID입니다.</p> + </div> + </Shell> + ) + } + + // 세션 체크 (EVCP 사용자만 접근 가능) + const session = await getServerSession(authOptions) + if (!session?.user) { + redirect("/") + } + + // 계약 상세 정보 조회 + const result = await getContractDetail(contractId) + + if (!result.success || !result.data) { + return ( + <Shell className="gap-4"> + <div className="flex h-full items-center justify-center p-6"> + <p className="text-muted-foreground">{result.error || "계약 정보를 찾을 수 없습니다."}</p> + </div> + </Shell> + ) + } + + return ( + <Shell className="gap-4"> + <ContractDetailClient contract={result.data} lng={params.lng} /> + </Shell> + ) +} + diff --git a/app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx b/app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx new file mode 100644 index 00000000..a6f7a729 --- /dev/null +++ b/app/[lng]/partners/(partners)/po/[id]/contract-detail-client.tsx @@ -0,0 +1,249 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import { ChevronLeft } from "lucide-react" +import Link from "next/link" +import { toast } from "sonner" +import { saveVendorComment, acceptContract, rejectContract } from "@/lib/po/vendor-table/service" +import { useRouter } from "next/navigation" +import { ContractInfoCard } from "@/components/contract/contract-info-card" +import { ContractItemsCard } from "@/components/contract/contract-items-card" + +interface ContractDetailClientProps { + contract: any + lng: string +} + +export function ContractDetailClient({ contract, lng }: ContractDetailClientProps) { + const router = useRouter() + const [vendorComment, setVendorComment] = React.useState(contract.vendorComment || "") + const [isLoading, setIsLoading] = React.useState(false) + const [isRejectDialogOpen, setIsRejectDialogOpen] = React.useState(false) + const [rejectReason, setRejectReason] = React.useState("") + + const handleSaveComment = async () => { + try { + setIsLoading(true) + const result = await saveVendorComment(contract.id, vendorComment) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error("의견 저장에 실패했습니다.") + } + } catch { + toast.error("의견 저장 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleApprove = async () => { + try { + setIsLoading(true) + const result = await acceptContract(contract.id) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error("계약 승인에 실패했습니다.") + } + } catch { + toast.error("계약 승인 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleRejectClick = () => { + setIsRejectDialogOpen(true) + } + + const handleRejectConfirm = async () => { + if (!rejectReason.trim()) { + toast.error("계약 반려 사유를 입력해주세요.") + return + } + + try { + setIsLoading(true) + const result = await rejectContract(contract.id, rejectReason) + if (result.success) { + toast.success(result.message) + setIsRejectDialogOpen(false) + setRejectReason("") + router.refresh() + } else { + toast.error("계약 거절에 실패했습니다.") + } + } catch { + toast.error("계약 거절 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleRejectCancel = () => { + setIsRejectDialogOpen(false) + setRejectReason("") + } + + return ( + <> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Link href={`/${lng}/partners/po`}> + <Button variant="ghost" size="icon"> + <ChevronLeft className="h-5 w-5" /> + </Button> + </Link> + <div className="flex items-center gap-3"> + <h1 className="text-2xl font-bold tracking-tight">계약 상세</h1> + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground">계약번호:</span> + <span className="text-sm font-medium">{contract.contractNo}</span> + <Badge variant="outline" className="ml-2"> + {contract.status} + </Badge> + </div> + </div> + </div> + <div className="flex gap-2"> + <Button + variant="outline" + onClick={handleSaveComment} + disabled={isLoading} + > + 의견저장 + </Button> + <Button + variant="default" + onClick={handleApprove} + disabled={isLoading} + > + 계약승인 + </Button> + <Button + variant="destructive" + onClick={handleRejectClick} + disabled={isLoading} + > + 계약반려 + </Button> + </div> + </div> + + {/* 계약 조건 */} + <ContractInfoCard contract={contract} /> + + {/* 코멘트 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">코멘트</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <label htmlFor="vendor-comment" className="text-sm font-medium"> + Vendor Comment + </label> + <textarea + id="vendor-comment" + className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="벤더 코멘트를 입력하세요..." + value={vendorComment} + onChange={(e) => setVendorComment(e.target.value)} + /> + </div> + <div className="space-y-2"> + <label htmlFor="shi-comment" className="text-sm font-medium text-muted-foreground"> + SHI Comment + </label> + <div className="w-full min-h-[100px] px-3 py-2 text-sm border rounded-md bg-muted/50"> + {contract.shiComment || "코멘트가 없습니다."} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 계약문서 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">계약문서</CardTitle> + </CardHeader> + <CardContent> + {contract.contractContent ? ( + <div className="prose prose-sm max-w-none"> + <pre className="whitespace-pre-wrap text-sm bg-muted/50 p-4 rounded-md"> + {contract.contractContent} + </pre> + </div> + ) : ( + <p className="text-sm text-muted-foreground">계약문서 내용이 없습니다.</p> + )} + </CardContent> + </Card> + + {/* 계약 품목 */} + <ContractItemsCard items={contract.items || []} currency={contract.currency} /> + + {/* 계약 반려 다이얼로그 */} + <Dialog open={isRejectDialogOpen} onOpenChange={setIsRejectDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>계약 반려</DialogTitle> + <DialogDescription> + 계약을 반려하는 사유를 입력해주세요. 이 정보는 SHI 담당자에게 전달됩니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4 py-4"> + <div className="space-y-2"> + <label htmlFor="reject-reason" className="text-sm font-medium"> + 반려 사유 <span className="text-destructive">*</span> + </label> + <Textarea + id="reject-reason" + placeholder="계약 반려 사유를 입력해주세요..." + value={rejectReason} + onChange={(e) => setRejectReason(e.target.value)} + rows={5} + className="resize-none" + /> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={handleRejectCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={handleRejectConfirm} + disabled={isLoading || !rejectReason.trim()} + > + {isLoading ? "처리 중..." : "반려 확정"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +} + diff --git a/app/[lng]/partners/(partners)/po/[id]/page.tsx b/app/[lng]/partners/(partners)/po/[id]/page.tsx new file mode 100644 index 00000000..8df2c90e --- /dev/null +++ b/app/[lng]/partners/(partners)/po/[id]/page.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { redirect } from "next/navigation" +import { getVendorContractDetail } from "@/lib/po/vendor-table/service" +import { Shell } from "@/components/shell" +import { ContractDetailClient } from "./contract-detail-client" + +interface ContractDetailPageProps { + params: Promise<{ + id: string + lng: string + }> +} + +export default async function ContractDetailPage(props: ContractDetailPageProps) { + const params = await props.params + const contractId = parseInt(params.id, 10) + + // 유효하지 않은 ID 체크 + if (isNaN(contractId) || contractId <= 0) { + return ( + <Shell className="gap-4"> + <div className="flex h-full items-center justify-center p-6"> + <p className="text-muted-foreground">유효하지 않은 계약 ID입니다.</p> + </div> + </Shell> + ) + } + + // 세션에서 벤더 정보 가져오기 + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + redirect("/") + } + + // 계약 상세 정보 조회 + const result = await getVendorContractDetail(contractId, session.user.companyId) + + if (!result.success || !result.data) { + return ( + <Shell className="gap-4"> + <div className="flex h-full items-center justify-center p-6"> + <p className="text-muted-foreground">{result.error || "계약 정보를 찾을 수 없습니다."}</p> + </div> + </Shell> + ) + } + + return ( + <Shell className="gap-4"> + <ContractDetailClient contract={result.data} lng={params.lng} /> + </Shell> + ) +} |
